分久必合,合久必分,即然有分支的功能,當然也可以將不同的分支合併起來。如果今天開了一個 bugfix 的分支用來修改 bug,在 bug 修好後,希望修好的程式碼也可以拿到 master 來用,這個時候就要來合併了。有的時候合併不一定是多個分支只留下一個,有可能只是將彼此的工作成果同步,但分支還是各自保留。合併基本上有兩種不同的作法,分別為 git merge 和 git rebase。在進行分支時,要考慮是那個分支要合併那個分支,請參考以下的情境。
第一種狀況是,要合併的兩個分支在提交記錄中位於同一條線,也就是說提交記錄是沒有岔開的。例如在 master 分支所在的提交開了新分支 bugfix,然後切換到 bugfix 分支進行新的提交,然而 master 分支並沒有新的提交。接續昨天 simplegit-progit 的範例,現在位於 bugfix 分支上,並進行了一次提交,結果如下:
* a4df2f4 (HEAD -> bugfix) add fix.txt
* ca82a6d (origin/master, origin/HEAD, master) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
可以看到現在位於 bugfix 分支(HEAD 指向 bugfix)的 a4df2f4 提交,而 master 分支位於它的前一個提交,ca82a6d。接下來要把 bugfix 這個分支合併到 master 分支。
首先切換到 master 分支,使用 git checkout master 這個指令。可以看到 HEAD 現在指向 master,表示已經切換到 master 分支。
* a4df2f4 (bugfix) add fix.txt
* ca82a6d (HEAD -> master, origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
接下來要合併 bugfix 分支,指令為 git merge bugfix,過程會產生類似下面的輸出。
Updating ca82a6d..a4df2f4
Fast-forward
fix.txt | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 fix.txt
這裡出現了 fast-forward 快進。這個合併其實很單純,只是將 master 分支的位置向前推進一個提交到 bugfix 分支所在的位置,所以叫作快進。看一下結果,確認 master 和 bugfix 位於同一個指標,這樣就合併完成了。
* a4df2f4 (HEAD -> master, bugfix) add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
第二種狀況,是提交記錄叉開的狀況。接續剛才的情境,在 master 分支上先進行一次提交。
* 1248772 (HEAD -> master) add new.txt
* a4df2f4 (bugfix) add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
接下來切換到 bugfix 分支,也進行一次提交。這裡刻意和剛才在 master 分支進行的提交,新增一個相同名稱的檔案,但內容不同。
* 822f997 (HEAD -> bugfix) add new.txt
| * 1248772 (master) add new.txt
|/
* a4df2f4 add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
可以看到提交記錄岔開了,現在來進行合併,一樣先切換到 master 分支,再將 bugfix 分支合併進來。git merge 的過程會產生如下輸出:
Auto-merging new.txt
CONFLICT (add/add): Merge conflict in new.txt
Automatic merge failed; fix conflicts and then commit the result.
這表示衝突 (conflict) 發生了,所謂的衝突是 Git 無法決定某個檔案的內容應該是什麼,例如這裡的狀況,兩個分支都新增了同一個檔案,但它們的內容卻不同,這時就必須手動決定該檔案的內容,再將這次的合併完成。使用 git status 看一下目前的狀態,會給予解決衝突的提示:
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both added: new.txt
把這個衝突的檔案加入 index,它會被視為衝突已解決,接下來就可以用 git commit 產生新的提交並完成這次的合併。在將衝突的檔案加到 index 前,當然要先決定一下它的內容。基本上它應該會是某種 diff 的格式,可以直接編輯。如果確定這個檔案的內容,要全部採用其中一個分支的提交,可以使用 git checkout --ours <file> 或 git checkout --theirs <file> 的指令,來更新該衝突檔案在工作目錄中的內容。這裡的 ours 和 theirs 是指誰呢?因為我們是在 master 分支要去合併 bugfix 分支,所以 ours 是 master (HEAD),theirs 是 bugfix。如果原本的衝突檔案改爛掉了,想回到合併後一開始衝突的狀態,可以用 git checkout --conflict merge <file> 這個指令。把檔案修好,加到 index ,再進行一個新的提交來結束這次的合併,結果如下。
* efa2733 (HEAD -> master) Merge branch 'bugfix'
|\
| * 822f997 (bugfix) add new.txt
* | 1248772 add new.txt
|/
* a4df2f4 add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
現在看到提交記錄多了 efa2733 這個提交,它是由 822f997 和 1248772 這兩個提交合併而成,而 HEAD 和 master 都移到 efa2733 這個提交了。現在可以把 bugfix 分支刪除,指令是 git branch -d bugifx,或者是將它保留。但是可能希望 bugfix 分支也有 master 分支的新成果,然後可以繼續在 bugfix 分支修復其他的 bug。此時可以拿 bugfix 分支來合併 master 分支。看一下提交記錄,會發現這個合併是 fast-forward 快進合併。
git merge 介紹完了,現在來介紹 git rebase。rebase 一詞在 Pro Git 中文版翻成衍合,這裡還是採用原文。rebase 的概念是這樣的:把在我這個分支做的事情,拿到你的分支再做一次。再使用一次上面的範例,不過我想先回到之前 master 分支和 bugfix 分支各有一次提交,尚未合併的狀態。想想看該怎麼做呢?
我剛才沒有拿 bugfix 分支來合併 master 分支,所以現在還是在 master 分支的 efa2733 提交。要回到 1248772 這個提交,可以用 git reset --hard 1248772 這個指令。它會將 HEAD 和 master 同時移到 1248772 提交,而且 HEAD 還是指向 master。--hard 選項表示同時要更新工作目錄與 index。結果如下:
* 822f997 (bugfix) add new.txt
| * 1248772 (HEAD -> master) add new.txt
|/
* a4df2f4 add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
接下來進行 rebase。這裡的操作是:我在 bugfix 分支,要把在 bugfix 分支做的事,在 master 分支再作一次。所以要先切到 bugfix 分支,然後在 master 進行 rebase。指令分別為 git checkout bugfix 和 git rebase master。過程輸出如下:
First, rewinding head to replay your work on top of it...
Applying: add new.txt
Using index info to reconstruct a base tree...
Falling back to patching base and 3-way merge...
Auto-merging new.txt
CONFLICT (add/add): Merge conflict in new.txt
error: Failed to merge in the changes.
Patch failed at 0001 add new.txt
The copy of the patch that failed is found in: .git/rebase-apply/patch
When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
不意外地又產生衝突了。這裡的提示說明當衝突解決時,可以用 git rebase --continue 來繼續。一樣執行 git status 來看一下解決衝突的方法,和前面提過的方式是相同的,也可以使用 git checkout --ours <file> 或 git checkout --theirs <file> 來決定採用那一方的版本。這裡的 ours 是 bugfix 分支,theirs 是 master 分支。
rebase in progress; onto 1248772
You are currently rebasing branch 'bugfix' on '1248772'.
(fix conflicts and then run "git rebase --continue")
(use "git rebase --skip" to skip this patch)
(use "git rebase --abort" to check out the original branch)
Unmerged paths:
(use "git reset HEAD <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both added: new.txt
接下來將衝突的檔案加入 index,並執行 git rebase --continue,結果如下:
* 844ff29 (HEAD -> bugfix) add new.txt
* 1248772 (master) add new.txt
* a4df2f4 add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
現在發現原來岔開的提交記錄變成一直線了,bugfix 分支跑到 master 分支之後,並且多出一個提交。對 bugfix 分支而言,它的歷史記錄改變了,原本是 a4df2f4 -> 822f997,變成 a4df2f4 -> 1248772 -> 844ff29,822f997 這個提交從歷史中消失了。實際上這個提交並沒有被刪除,只是在 bugfix 分支中參考不到它。詳細的過程可以參考 Pro Git 的說明,這裡我們只要記得 rebase 是將 bugfix 分支做的事在 master 上再做一次,而對 bugfix 分支而言,它會改變歷史。到這裡還沒有全部完成,因為 master 分支並沒有移動,表示它還沒有納入 bugfix 分支的成果,所以接下來必須切回 master 分支,然後進行一次快進合併,指令分別為 git checkout master 及 git merge bugfix,最後結果如下:
* 844ff29 (HEAD -> master, bugfix) add new.txt
* 1248772 add new.txt
* a4df2f4 add fix.txt
* ca82a6d (origin/master, origin/HEAD) changed the verison number
* 085bb3b removed unnecessary test code
* a11bef0 first commit
git rebase 另外有互動模式,可以挑選想要保留的提交,以上面的例子,就是可以在 bugfix 分支中選擇我們要的提交,並根據這些挑選出來的提交,在 master 分支上重做。有一個類似可以挑選提交的指令是揀櫻桃 cherry-pick。這部分請再參考文件的說明。
終於把合併講完,現在要進入第三個維度,也就是如何和別人合作。當進行團隊合作時,會需要一個中間者,來讓大家協調同步彼此的狀態,而新加入的成員也可以從中間者取得目前的程式碼或文件,這個可以讓團隊成員取得程式碼的中間者,對其他人而言就是一個遠端儲存庫 (remote repository)。遠端儲存庫通常會放在某台伺服器上讓大家可以存取,遠端儲存庫不會拿來開發,所以它不會有工作目錄存在。它除了管理同步大家的開發狀態外,還可以跟其他工具整合,例如 Docker、Jenkins,作為持續整合/交付的一環。
Git 本身就提供了架設 Git 遠端儲存庫的功能,也可使用 GitLab 這類的工具,它提供了方便友善的網頁介面,讓我們可以快速地建立新的專案儲存庫,或與其他工具流程進行整合。如果不想自行架設,目前市面上有一些基於 Git 的源碼託管平台可以利用,例如 GitHub、GitLab、Bitbucket 等等。
接下來我們看一下如何和遠端儲存庫進行互動。以我個人的經驗,大概有以下三類型的場景:
一、知道遠端儲存庫的位置,然後要拿一份到本機進行開發
一個專案在遠端儲存庫通常是透過 HTTP 或者 SSH 的方式取回至本機。遠端儲存庫的位置大概是 http://test.com/project.git 這樣的形式,由協定、位址、專案名稱組成。初次取回的動作叫克隆 (clone),指令為 git clone,這個已經使用過了。如果不指定本機目錄,會在當下目錄建立一個和專案名稱同名的目錄,然後將取回的內容放在這個目錄裡。取回儲存庫後,Git 會使用最新一次提交的內容來更新工作目錄。
二、建立一個新的遠端儲存庫,然後把本機已經存在的檔案放上去讓他人存取
如果在架設 Git 伺服器時就選擇 GitLab 這類的工具,那麼要建立一個新的遠端儲存庫只要在網頁介面上輸入一些資訊,再點幾個按鈕就可以完成。若使用 Git 指令來新建儲存庫倒也不難,只是必須另外進行登入方式、帳號等等的設定,這部分就請大家自行查閱,這裡只介紹建立儲存庫的指令。假設要在家目錄下建立一個遠端儲存庫 my-project.git,在家目錄下執行下述指令:
$ git init --bare my-project.git
會產生 my-project.git 目錄。接著要把本機的檔案丟上去。為了示範,在本機上同時建立遠端儲存庫,以及開發用的專案目錄。假設開發專案目錄是 my-project,放在家目錄下,請自行在專案目錄中放置幾個檔案。接下要讓這個目錄被 Git 納管。複習一下步驟,進入 my-project 目錄後,執行 git init、git add 及 git commit。然後要把 my-project 的內容丟到 my-project.git 這個儲存庫,這裡有幾個步驟。
告訴 Git 遠端儲存庫的位置。遠端儲存庫可以給它一個名稱,通常會叫它 origin,指令為 git remote add <name> <url>。在上面的例子,先進入 my-project 資料夾,此時來指定遠端儲存庫,名稱為 origin,位置為 ~/my-project.git,指令是 git remote add origin ~/my-project.git
把本地的 master 分支推 (push) 到遠端儲存庫,這個動作會在遠端儲存庫建立一個相對應的分支(這裡為 master 分支),並且將本地端的資料丟上去,指令為 git push origin master。如此一來本地端的資料就同步到遠端儲存庫中了。
三、在本地作了一些修改後,同步到遠端儲存庫
有可能要將本地修改同步到遠端儲存庫時,也有他人作了修改,這個時候若要 push 回去會失敗,git 會希望使用者 自行處理和其他人工作成果 merge 的操作,所以第一步會先把遠端儲存庫的資料先拉下來。遠端儲存庫一樣是 origin,指令為 git fetch origin。假設有他人作了修改,所以 fetch 後 origin/master 分支的位置會改變,可以用 git log origin/master 來查看此遠端分支的提交狀況。而在本地端,我們目前還在 master 分支,首先先合併剛才拉下來的他人工作成果,可以使用 git merge origin/master 指令。合併完成後應該會更改 master 分支的位置,此時 master 分支已包含其他人的工作成果。接下來我們就可以用 git push origin master,把在 master 分支的工作成果推回 origin 遠端儲存庫。
Submodule 大概可以翻成「子模組」或「次模組」,之前沒有聽過這個工具,但是在應試目標能力有提到,所以就來研究一下。Submodule 的基本精神就是一個 Git 專案目錄中包含另一個 Git 專案目錄,但希望這兩個專案在操作時可以分開,例如有分開的提交記錄等等,在實務上可能是需要使用另一個程式庫,有可能在開發時會對這個程式庫進行更改,或者希望這個程式庫的遠端儲存庫有更新時也能在本端拉回更新。
假設有一個主專案,它的儲存庫位置是 https://github.com/chaconinc/MainProject,而另一個要作為 submodule 的儲存庫是 https://github.com/chaconinc/DbConnector。首先是在本地的 MainProject 專案目錄中加入 DbConnector 作為 submodule,指令是
$ git submodule add https://github.com/chaconinc/DbConnector
如同 git clone,它會建立一個 DbConnector 的目錄,然後把專案複製進來。這裡沒有特別說明 DbConnector 目錄應該直接置於 MainProject 之下,或者 MainProject 中任何一個子目錄都可以,假設它直接位於 MainProject 之下的第一層。
接下來用 git status 看一下,除了 DbConnector 外,還會多一個 .gitmodules 檔案,它會記住 submodule 的遠端儲存庫位置以及本地的路徑。這個檔案也是要被 Git 納管的。然後是 git add 和 git commit,都是在 DbConnector 目錄之外進行的操作,所以提交是屬於 MainProject 的記錄。最後用 git push origin master 將變動推回遠端儲存庫。
要克隆一個有 submodule 的專案,一開始的步驟和克隆一般的專案相同:
$ git clone https://github.com/chaconinc/MainProject
克隆下來的本地工作目錄會包含 DbConnector 這個 submodule 的目錄,但是是空的,必須自己手動將內容拉回來,指令是 git submodule init 及 git submodule update。這些步驟可以合併成下面的指令:
$ git clone --recurse-submodules https://github.com/chaconinc/MainProject
因為個人懶惰的關係,submodule 就先介紹到這裡,我覺得這個功能好像比較少用到,所以大概知道 submodule 是什麼就可以了,關於後續的操作請再參考文件說明。
結語
關於 Git 就先介紹到這裡,我們先從在本地端工作目錄新增 git 儲存庫,將檔案加入暫存區並提交開始,接下來提到一些檔案或狀態回復的方式,再介紹分支及合併的概念,最後介紹遠端儲存庫和與他人合作開發的流程以及 submodule。Git 本身是個功能很強大的工具,這幾天也覺得好像還有很多東西沒有提到,但總之希望還沒在開發過程中使用源碼管理工具的朋友,可以試著使用看看 Git,相信它不會讓你失望的。
對於 Git 有興趣的朋友,接下來可以去瞭解 Git 的內部對於檔案、提交這些物件的表示方法。最後附上一些學習資源,首先是官網的 Pro Git,雖然內容有點多,但謮完後會對 Git 有很清楚的概念。然後是高見龍大大的《為你自己學 Git》,以問答的方式說明 Git 在特定情境下的操作方法,尤其是對 Git 內部物件的說明,看完後會功力大增,最後是保哥前幾年的鐵人賽系列文,請參考 https://github.com/doggy8088/Learn-Git-in-30-days。那麼 Git 就到這裡了。